xNode的魔改记录

xNode的魔改记录

目录

一、接入Odin
二、修改Node的标题
三、替换DynamicPortList
四、修改Node创建菜单


想尝试一下制作一个可视化的剧情编辑器,于是翻阅了诸多文章后找到了开源的xNode。

xNode地址:https://github.com/Siccity/xNode

但是由于xNode的部分功能不是很符合我的需求,于是乎开始动手魔改,写下此文章以记录魔改过程。

一、接入Odin

xNode虽然兼容Odin插件,但是其本身的NodeNodeGraph还是使用ScriptableObject进行序列化的。因此我们可以将其换为Odin里的SerializedScriptableObject,这样可以使Node和Graph更好的储存诸如DictionaryHashset之类的数据。

NodeGraph修改

打开NodeGraph.cs
NodeGraph本身涉及的数据较少,直接将ScriptableObject替换成SerializedScriptableObject,并删除所有的[SerializeField][Serializable]即可。

using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;

namespace XNode {
    public abstract class NodeGraph : SerializedScriptableObject 
    {
        [HideInInspector]
        public List<Node> nodes = new List<Node>();
        ......
    }
}

[HideInInspector]标签是为了让被Odin序列化的属性不会出现在面板上

Node修改

打开Node.cs文件
与NodeGraph一样,将ScriptableObject替换成SerializedScriptableObject,并删除[SerializeField][Serializable]

Node和NodeGraph不同的是,Node里原本使用了一个自建的NodePortDictionary来替代Dictionary,并为其自定义了序列化方法。但是我们使用SerializedScriptableObject的话,可以让Odin帮我们完成Dictionary的序列化。因此将ports变量的类型从NodePortDictionary改成Dictionary<string, NodePort>,并添加标签[OdinSerialize]

using System;
using System.Collections.Generic;
using Sirenix.OdinInspector;
using UnityEngine;

namespace XNode
{
    public abstract class Node : SerializedScriptableObject
    {
        ......
        [HideInInspector]
        public NodeGraph graph;

        [HideInInspector]
        public Vector2 position;

        [OdinSerialize]
        [HideInInspector]
        private Dictionary<string, NodePort> ports = new Dictionary<string, NodePort>();
        ......
    }
}

二、修改Node的标题

xNode里,Node默认使用的是name属性作为标题,而name属性则是由脚本名称自动生成的。虽然node可以进行rename,但是这样的显示方法还是不够友好。
我所希望的理想方式是能显示自定义的Node注释,并且可以控制是否显示自定义名称,如:对话 (游戏开始) 或 剧情开始
而自定义的方式,使用特性标签(Attribute)是较为合适的。

打开Node.cs文件,找到#region Attributes部分,在里面添加新的Attribute

/// <summary>
/// Add custom title for node
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class NodeTitleAttribute : Attribute
{
    public string title;
    public bool allowCustomName;
    // allowCustomName为是否显示Node的name
    public NodeTitleAttribute(string title,bool allowCustomName = true)
    {
        this.title = title;
        this.allowCustomName = allowCustomName;
    }
}

打开NodeEditor.cs文件,找到public virtual void OnHeaderGUI()函数,修改如下:

private string title;
private bool allowCustomName;

// 获取自定义的标题Attribute
public override void OnCreate()
{
    var nodeTitleAttribute = target.GetType().GetCustomAttribute<Node.NodeTitleAttribute>();
    if (nodeTitleAttribute != null)
    {
        title = nodeTitleAttribute.title;
        allowCustomName = nodeTitleAttribute.allowCustomName;
    }
    if (string.IsNullOrEmpty(title))
        title = target.GetType().Name;
    
    Debug.Log("OnCreate");
}

public virtual void OnHeaderGUI()
{
    // 绘制自定义的标题头
    if(allowCustomName)
        GUILayout.Label($"{title} ({target.name})",
            NodeEditorResources.styles.nodeHeader,
            GUILayout.Height(30));
    else
        GUILayout.Label(title, NodeEditorResources.styles.nodeHeader, GUILayout.Height(30));
}

此时通过添加标签[NodeTitle("对话")]即可让Node显示自定义的标题:

自定义的Node头

三、替换DynamicPortList

xNode里有一个实用的功能,动态端口列表(dynamicPortList)。可以通过列表来自动生成对应的端口(port),一个列表元素对应一个port。使用方法也很简单,在例如List<string> options之类的字段前加上[Output(dynamicPortList = true)][Inputput(dynamicPortList = true)]即可。

但是xNode的DynamicPortList使用的是Unity自带的ReorderableList进行绘制的,不仅不是很美观,Odin相关的Attribute也全部失效了,如下图:

打上了Odin的Attribute

DynamicPortList
xNode原版的DynamicPortList,Attribute全部失效

要想修改这种项的绘制,只能使用Unity原生的PropertyDrawer,但这样麻烦不说,还失去了我们接入Odin的初衷:高效、省时、省力。因此,最好还是想办法让其使用Odin的绘制流程来进行绘制。

在装上Odin插件后,xNode里会由OutputAttributeDrawer.cs来接管Output的绘制(Input同理)

首先找到OutputAttributeDrawer.cs里的DrawPropertyLayout(GUIContent label)函数,找到如下语句:

NodeEditorGUILayout.PropertyField(portPropoerty, label == null ? GUIContent.none : label, 
  true, GUILayout.MinWidth(30));

在此处进行判断,非dynamicPortList的变量仍使用该语句渲染,而dynamicPortList则使用我们修改的方法:

if (Attribute.dynamicPortList)
{
    CallNextDrawer(label);
    //NodeEditorGUILayout.DrawDynamicPortList(Property,NodePort.IO.Output,
        //Attribute.connectionType,Attribute.typeConstraint);
}
else
{
    NodeEditorGUILayout.PropertyField(portPropoerty, label == null ? GUIContent.none : label, 
        true, GUILayout.MinWidth(30));
}

其中,CallNextDrawer(label);是Odin的Draw里正常渲染的方法,我们使用该方法先把列表渲染出来。而NodeEditorGUILayout.DrawDynamicPortList将是我们需要使用的渲染动态port的新方法,由于目前还没有写,暂时先注释掉。

回到Unity,可以看到xNode里变成了如下样式


xNode里的样子

列表的端口单独独立出来了,且列表项没有被绘制。

首先解决列表项没有被绘制的问题,该处是因为Output标签对列表里的子元素也生效了,而因此子元素也受到了OutputAttributeDrawer的影响,没能被绘制出来。解决方法很简单,找到Node.cs里的OutputAttribute类,在前面加上[DontApplyToListElements]标签即可,如:

[AttributeUsage(AttributeTargets.Field)]
[DontApplyToListElements]
public class OutputAttribute : Attribute
{
    ......
}

回到xNode,此时子元素已经被正确的绘制出来了,但是依然有独立的port被绘制在外面。


如 Options 0 和 Options 1 两个端口

这是因为这些dynamic port没有被xnode的dynamicPortList绘制后,就会被自动以普通端口的形式进行绘制,具体逻辑在NodeEditor.csOnBodyGUI()函数里,如下:

foreach (XNode.NodePort dynamicPort in target.DynamicPorts) 
{
    if (NodeEditorGUILayout.IsDynamicPortListPort(dynamicPort)) continue;
    NodeEditorGUILayout.PortField(dynamicPort);
}

因此,我们只需要修改NodeEditorGUILayout.IsDynamicPortListPort的判断,让其将这些动态端口识别出来并跳过处理即可。

跳转到其函数位置,将其修改如下:

public static bool IsDynamicPortListPort(XNode.NodePort port) 
{
    string[] parts = port.fieldName.Split(' ');
    if (parts.Length != 2) return false;
    return true;
    // Dictionary<string, ReorderableList> cache;
    // if (reorderableListCache.TryGetValue(port.node, out cache)) {
    //     ReorderableList list;
    //     if (cache.TryGetValue(parts[0], out list)) return true;
    // }
    // return false;
}

xNode里,动态端口的命名是 字段名+" "+序号,因此,我们只需要判断出来其中包含一个空格即可。如果有其他需求可自行修改。

返回Unity,此时显示如下:

子元素显示正常,dynamic port没有额外显示

此时,我们需要开始添加自己的绘制Port的逻辑。返回到OutputAttributeDrawer.cs,取消之前注释的NodeEditorGUILayout.DrawDynamicPortList函数。

打开NodeEditorGUILayout.cs文件,在末尾添加以下函数:
DrawDynamicPortList

public static void DrawDynamicPortList(InspectorProperty property,NodePort.IO portType,
Node.ConnectionType connectionType,Node.TypeConstraint typeConstraint)
{
    Node node = property.Parent.ValueEntry.WeakSmartValue as Node;
    // 判断是否是node的字段
    if(node == null)
        return;
    // 修改DynamicPort的数量,使之与List的大小对应
    OnDynamicPortChange(property, node, portType, connectionType, typeConstraint);
    // 绘制Port
    for (int i = 0; i < property.Children.Count; i++)
    {
        NodePort port = node.GetPort($"{property.Name} {i}");
        if(port == null)
            return;
        var propertyChild = property.Children.Get(i);
        DrawDynamicPortListItem(propertyChild, i,port);
    }
}

OnDynamicPortChange

public static void OnDynamicPortChange(InspectorProperty property,Node node,
NodePort.IO portType,Node.ConnectionType connectionType,Node.TypeConstraint typeConstraint)
{
    property.Update();
    var dynamicDic = node.DynamicPorts.Where(
        port =>
        {
            string[] names = port.fieldName.Split(' ');

            return names.Length == 2 && names[0] == property.Name;
        }).ToDictionary(port => port.fieldName);

    for (int i = 0; i < property.Children.Count; i++)
    {
        string portName = $"{property.Name} {i}";
        var propertyChildren = property.Children.Get(i);
        if (dynamicDic.ContainsKey(portName))
        {
            dynamicDic.Remove(portName);
        }
        else
        {
            if (portType == NodePort.IO.Input)
            {
                node.AddDynamicInput(propertyChildren.ValueEntry.BaseValueType, 
                    connectionType, typeConstraint, portName);
            }
            else
            {
                node.AddDynamicOutput(propertyChildren.ValueEntry.BaseValueType, 
                    connectionType, typeConstraint, portName);
            }
        }
    }
    // 删除多余port
    foreach (var nodePort in dynamicDic)
    {
        node.RemoveDynamicPort(nodePort.Value);
    }
}

DrawDynamicPortListItem

public static void DrawDynamicPortListItem(InspectorProperty property,int index,NodePort port)
{
    Rect rect = property.LastDrawnValueRect;
    if (port.direction == NodePort.IO.Input)
    {
        rect.position = rect.position + new Vector2(-16, 0);
    }
    else
    {
        rect.position = rect.position + new Vector2(rect.width+20, 0);
    }

    rect.height = EditorGUIUtility.singleLineHeight;
    rect.size = new Vector2(16, 16);

    var portPos = rect.center;
    NodeEditor.portPositions[port] = portPos;
    PortField(rect.position,port);
}

DrawDynamicPortList函数负责收集Port的信息,并调用刷新函数和绘制函数。
OnDynamicPortChange则是在List的大小变更后,对应调整Port的数量。
DrawDynamicPortListItem负责渲染每一个子元素对应的port

保存后,打开Unity,可以看到列表和对应的Port都能正常显示和使用了。

成功显示

此处只修改了Output的Attribute,对于Input修改方法一样。

但是当前的List依然存在几个问题

  1. 调整List里的元素顺序后,Port并不会跟随一起变动
  2. 当拖拽、折叠、翻页时,由于rect获取异常,所有的端口都会堆积在左上角。

为此,还需要进一步的修改。

List元素变动时,修改Port

打开Node.cs文件,在Node类里插入以下内容

// 用于临时记录index的变量
private int _lastDynamicPortIndex;
public void OnDynamicPortListChange(InspectorProperty property,CollectionChangeInfo changeInfo, object value)
{
    if (changeInfo.ChangeType == CollectionChangeType.RemoveIndex)
    {
        // 记录上一个移动的列表项的Index
        // 虽然删除列表元素时也会触发该回调,但是移动操作会同时触发移除和插入操作,因此不用担心冲突问题。
        _lastDynamicPortIndex = changeInfo.Index;
    }
    else if(changeInfo.ChangeType == CollectionChangeType.Insert)
    {
        string fieldName = property.Name;
        // 上移Port的Connection
        if (changeInfo.Index > _lastDynamicPortIndex) {
            for (int i = _lastDynamicPortIndex; i < changeInfo.Index; ++i) {
                NodePort port = GetPort(fieldName + " " + i);
                NodePort nextPort = GetPort(fieldName + " " + (i + 1));
                port.SwapConnections(nextPort);
            }
        }
        // 下移Port的Connection
        else {
            for (int i = _lastDynamicPortIndex; i > changeInfo.Index; --i) {
                NodePort port = GetPort(fieldName + " " + i);
                NodePort nextPort = GetPort(fieldName + " " + (i - 1));
                port.SwapConnections(nextPort);
            }
        }
    }
}

随后回到添加了dynamicPortList的变量边上,加入标签[OnCollectionChanged(After = "OnDynamicPortListChange")]

[Output(backingValue = ShowBackingValue.Never,
        connectionType = ConnectionType.Override,
        dynamicPortList = true)]
[OnCollectionChanged(After = "OnDynamicPortListChange")]
public List<OptionData> options = new List<OptionData>();

该标签会在List的面板上发生列表变动后,调用Node基类里的OnDynamicPortListChange函数回调。
这样在List里调整元素顺序时,Port也会相应变动了。

解决异常渲染的Port

找到之前在NodeEditorGUILayout类里添加的DrawDynamicPortListItem函数,将其修改为如下

public static void DrawDynamicPortListItem(InspectorProperty property,int index,NodePort port)
{
    Rect rect = property.LastDrawnValueRect;
    // 判断Port是否能渲染在正确的位置,当被折叠或翻页时,Port改为渲染到父控件的边上
    bool isShowing = rect != Rect.zero && property.Parent.State.Expanded;
    if (isShowing)
    {
        if (port.direction == NodePort.IO.Input)
        {
            rect.position = rect.position + new Vector2(-16, 0);
        }
        else
        {
            rect.position = rect.position + new Vector2(rect.width+20, 0);
        }
    }
    else
    {
        rect = property.Parent.LastDrawnValueRect;
        if (port.direction == NodePort.IO.Input)
        {
            rect.position = rect.position + new Vector2(0, 0);
        }
        else
        {
            rect.position = rect.position + new Vector2(rect.width, 0);
        }
    }
    
    
    rect.height = EditorGUIUtility.singleLineHeight;
    rect.size = new Vector2(16, 16);

    var portPos = rect.center;
    NodeEditor.portPositions[port] = portPos;
    PortField(rect.position,port);
}

这样Port在List折叠或翻页时,也能显示在正确的位置上了。


正常情况
折叠时
翻页时

四、修改Node创建菜单

xNode的Graph界面,右键菜单默认是显示所有的Node,但是当Node很多的时候,查找起来相当的不方便。因此,我们可以使用Odin的Selector来替代Node的创建菜单。

添加新的Node选择器

打开NodeGraphEditor.cs,找到AddContextMenuItems函数,该函数是用于控制右键菜单弹出时添加的内容。
将其修改如下:

public virtual void AddContextMenuItems(GenericMenu menu, Type compatibleType = null, 
    XNode.NodePort.IO direction = XNode.NodePort.IO.Input) 
{
    var mousePosition = Event.current.mousePosition;
    Vector2 pos = NodeEditorWindow.current.WindowToGridPosition(mousePosition);

    var nodesCreator = GetGenericSelector(compatibleType, direction, pos);
    
    menu.AddItem(new GUIContent("创建节点"),false, () =>
    {
        nodesCreator.ShowInPopup(mousePosition);
    });
    menu.AddSeparator("");
    if (NodeEditorWindow.copyBuffer != null && NodeEditorWindow.copyBuffer.Length > 0) menu.AddItem(new GUIContent("Paste"), false, () => NodeEditorWindow.current.PasteNodes(pos));
    else menu.AddDisabledItem(new GUIContent("粘贴"));
    menu.AddItem(new GUIContent("偏好设置"), false, () => NodeEditorReflection.OpenPreferences());
    menu.AddCustomContextMenuItems(target);
}

public GenericSelector<Type> GetGenericSelector(Type compatibleType,
    NodePort.IO direction, Vector2 pos)
{
    Type[] nodeTypes;

    if (compatibleType != null && NodeEditorPreferences.GetSettings().createFilter)
    {
        nodeTypes = NodeEditorUtilities
            .GetCompatibleNodesTypes(NodeEditorReflection.nodeTypes, compatibleType, direction)
            .OrderBy(GetNodeMenuOrder).ToArray();
    }
    else
    {
        nodeTypes = NodeEditorReflection.nodeTypes.OrderBy(GetNodeMenuOrder).ToArray();
    }

    Dictionary<Type, string> typesCache = new Dictionary<Type, string>();
    for (int i = 0; i < nodeTypes.Length; i++)
    {
        Type type = nodeTypes[i];
        
        string path = GetNodeMenuName(type);
        if (string.IsNullOrEmpty(path)) continue;
        
        XNode.Node.DisallowMultipleNodesAttribute disallowAttrib;
        bool disallowed = false;
        if (NodeEditorUtilities.GetAttrib(type, out disallowAttrib))
        {
            int typeCount = target.nodes.Count(x => x.GetType() == type);
            if (typeCount >= disallowAttrib.max) disallowed = true;
        }
        
        if (!disallowed)
        {
            typesCache.Add(type, $"{path} ({NodeEditorUtilities.NodeDefaultName(type)})");
        }
    }

    GenericSelector<Type> nodesCreator = new GenericSelector<Type>("选择节点", false,
        x => typesCache[x], typesCache.Keys);
    nodesCreator.SelectionTree.Config.DrawSearchToolbar = true;
    nodesCreator.SelectionTree.Config.AutoFocusSearchBar = true;
    nodesCreator.SelectionTree.Config.ConfirmSelectionOnDoubleClick = true;
    nodesCreator.SelectionConfirmed += col =>
    {
        XNode.Node node = CreateNode(col.FirstOrDefault(), pos);
        NodeEditorWindow.current.AutoConnect(node);
    };
    return nodesCreator;
}

此时,Graph界面里,右键会变成如下样式:


右键菜单

而点击创建节点后,会出现如下菜单:

节点创建菜单

此时,初步的改造已经完成了。但是,以往右键直接打开创建菜单的方式变成了如今的两步点击,是很不方便的。因此我们还可以进行进一步的优化:

添加快捷键

打开NodeEditorAction.cs,搜索case EventType.KeyDown:,在该case语句的末尾,添加新的按键判断:

case EventType.KeyDown:
    if (EditorGUIUtility.editingTextField || GUIUtility.keyboardControl != 0) break;
    else if (e.keyCode == KeyCode.F) Home();
    if (NodeEditorUtilities.IsMac()) {
        if (e.keyCode == KeyCode.Return) RenameSelectedNode();
    } else {
        if (e.keyCode == KeyCode.F2) RenameSelectedNode();
    }
    if (e.keyCode == KeyCode.A) {
        if (Selection.objects.Any(x => graph.nodes.Contains(x as XNode.Node))) {
            foreach (XNode.Node node in graph.nodes) {
                DeselectNode(node);
            }
        } else {
            foreach (XNode.Node node in graph.nodes) {
                SelectNode(node, true);
            }
        }
        Repaint();
    }

    if (e.keyCode == KeyCode.Space)
    {
        var mousePosition = e.mousePosition;
        Vector2 pos = WindowToGridPosition(mousePosition);
        var nodesCreator = graphEditor.GetGenericSelector(null, 
            NodePort.IO.Input, pos);
        nodesCreator.ShowInPopup(mousePosition);
    }
    break;

回到xNode里,此时按Space键能快速打开节点选择器

修改拖拽端口时弹出的菜单

搜索else if (draggedOutputTarget == null && NodeEditorPreferences.GetSettings().dragToCreate && autoConnectOutput != null)
将其if语句块内的代码修改如下:

else if (draggedOutputTarget == null && NodeEditorPreferences.GetSettings().dragToCreate 
    && autoConnectOutput != null) 
{
    var mousePosition = e.mousePosition;
    Vector2 pos = WindowToGridPosition(mousePosition);
    var nodesCreator = graphEditor.GetGenericSelector(draggedOutput.ValueType, 
        NodePort.IO.Input, pos);
    nodesCreator.ShowInPopup(mousePosition);
}

这样在拖拽端口后,可以快速打开节点选择器。

拖拽端口创建节点

修改拖拽端口创建节点时的逻辑判断

在测试DynamicPortList时,我发现即使Input端口的TypeConstraint已经设置成了TypeConstraint.None,但是在拖拽时依然无法弹出可选的节点列表,如图:

从DynamicPortList的端口拖动来创建节点

分析代码时,发现是由于判断端口是否能连接的函数HasCompatiblePortType里,没有判断Attribute的typeConstraint所导致的。

打开NodeEditorUtilities.cs文件,找到HasCompatiblePortType方法,将其替换如下:

public static bool HasCompatiblePortType(Type nodeType, Type compatibleType, 
XNode.NodePort.IO direction = XNode.NodePort.IO.Input) 
{
    Type findType = typeof(XNode.Node.InputAttribute);
    if (direction == XNode.NodePort.IO.Output)
        findType = typeof(XNode.Node.OutputAttribute);

    //Get All fields from node type and we go filter only field with portAttribute.
    //This way is possible to know the values of the all ports and if have some with compatible value tue
    foreach (FieldInfo f in XNode.NodeDataCache.GetNodeFields(nodeType)) {
        var portAttribute = f.GetCustomAttributes(findType, false).FirstOrDefault();
        if (portAttribute != null) {
            switch (portAttribute)
            {
                case Node.InputAttribute inputAttribute:
                    if (inputAttribute.typeConstraint == Node.TypeConstraint.None)
                        return true;
                    break;
                case Node.OutputAttribute outputAttribute:
                    if (outputAttribute.typeConstraint == Node.TypeConstraint.None)
                        return true;
                    break;
            }
            if (IsCastableTo(f.FieldType, compatibleType)) {
                return true;
            }
        }
    }

    return false;
}

我们新增了端口特性的判断后,便可以通过拖拽来创建并连接端口类型不同的节点了。

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

推荐阅读更多精彩内容